Repository navigation
feat(mcp): read_file and create_upload_link read and attach a record's files - #31009
objectstack-fleet[bot] wants to merge 10 commits into
Conversation
…s files (#30892) Two MCP tools over the storage plugin's file-attachments service: - read_file reads a file a record carries (a file field's, or an attachment) as the caller: the record is read through the get_record door first, the file is judged by the download door's own verdict, and the answer carries the size, the byte cap, a short-lived download link and the extracted text (text, PDF via unpdf 1.9.1, .docx) or the image. - create_upload_link mints a single-use upload link bound to one record and field (or the attachments list). The link is a pending sys_file row holding the SHA-256 of a 256-bit secret; one conditional write spends it; the upload acts as the minting user, re-resolved at use, and meets max_upload_mb before the link is spent. Work in progress: runtime tests, docs and changeset follow. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4cyqd3A7fbCWrqM3KCz
…howcase, docs and changeset (#30892) The dogfood file drives both tools over /api/v1/mcp as a member and the admin on the showcase's invoice graph (owner RLS, controlled_by_parent, a receipt file field, enable.files attachments), spends links through the real PUT route, and pins access, the binding, single use, expiry and the upload limit. Docs list the two tools and the two routes; one changeset for the three published packages. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4cyqd3A7fbCWrqM3KCz
…d system-context censuses (#30892) The upload link's field write and the conditional spend are two engine write call sites (263, from 261), regenerated with `tenant-audit-census.mjs --write` and the page's hand-stated figures moved to match. Row 47e names the record-file verbs beside the attach verbs: both refuse the system context through a `requireUserContext`, so the row's key is now pinned per file. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4cyqd3A7fbCWrqM3KCz
…uest seams (#30892) `handleMcp` now builds the bridge as `buildMcpBridge(deps, context, { recordFiles })`. The pin still requires the request context as the second argument and only admits a trailing seams object literal, so a bridge built from anything else still drops the key and goes STALE. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4cyqd3A7fbCWrqM3KCz
…r, signed in (#30892) Both upload-link doors now read the request's own session first. No session answers 401 AUTH_REQUIRED, as every other storage door does, so the link is not an anonymous door (ADR-0138 D2) and the anonymous sweep's everything-else class holds. A session naming another user answers the one unusable-link 404 and spends nothing. The page's PUT carries the session cookie. The MCP tool, the served skill, the docs and the changeset say the link is opened signed in. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4cyqd3A7fbCWrqM3KCz
Brings in #30861 (delete_record confirm) and the rest of main before the draft PR opens. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4cyqd3A7fbCWrqM3KCz
📓 Docs Drift CheckThis PR changes 3 package(s): 81 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 19 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 40 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0881527c35341632639f21511b66942077b32d19 && git checkout 0881527c35341632639f21511b66942077b32d19
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 41dc302496e48ed23d3dcd23f61d6365448237eb 57724d86781f4f40c6f3f26b3c53fd6842e8a626 && git checkout -B drift-repro 41dc302496e48ed23d3dcd23f61d6365448237eb && git merge --no-ff 57724d86781f4f40c6f3f26b3c53fd6842e8a626
node scripts/docs-audit/affected-docs.mjs --json 41dc302496e48ed23d3dcd23f61d6365448237eb
|
…gfood typecheck passes (#30892) The PUT helper passed a Node Buffer as fetch's body, which tsc refuses as BodyInit (TS2322). Send a Uint8Array view, as the storage upload-limits dogfood does. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4cyqd3A7fbCWrqM3KCz
…ing collection (#30892) After main's merge the dispatcher reaches standalone-stack.ts, whose function body import()s @objectstack/metadata; check:test-source-alias asks the changed test to load it at module top so no clocked case pays the transform. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4cyqd3A7fbCWrqM3KCz
|
domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH Contract reviewServed-tier: Inputs read: card #30892 body and all seven comments (triage 6685125943, seat 4's two claims 6685222837 / 6685332126, the progress ping 6689332927, seat 4's brief 6690566522, the ruling 6691366983, the dev's current os-dev report 6691432611 on head ① Derived judgments
② Semver levelBoth carriers read Check-runs on ③ Boundary flags
④ Design judgment
Findings (none must-fix):
Nits:
Implemented-by: VERDICT: PASS Record written 2026-11-06T17:58Z. |
…s the row's uploader (#30892) The link row's name and metadata columns are not readonly, so openLink now also requires the binding's user to be the row's owner_id, the readonly uploader column minting writes. A row rebound to another user answers the same unusable-link 404 and spends nothing. The module doc states that the binding's other columns rest on sys_file edit being administrative. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibzuTqmB5hqFYmMATiY
The tenant-audit census conflicted: main and this branch each moved its figures. Resolved by taking main's artefacts and regenerating them with tenant-audit-census.mjs --write over the merged tree (265 write call sites), then moving the page's hand-stated figures to match. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibzuTqmB5hqFYmMATiY
|
domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH 维护者速读
席位意见:可以落地(待 os-zhuang 批准)。 席位核对ACCEPT · seat
|
Requested by Elon · project thread
domain:server#4 · os-elon-musk · session_016WV4cyqd3A7fbCWrqM3KCz
Fixes #30892
Clause-②: yes (widening) — two MCP tools join the HTTP surface where the storage service is installed (
read_fileunderdata:read,create_upload_linkunderdata:write), and two signed-in routes join the storage family (GETandPUT /api/v1/storage/upload-links/:token). Nothing that answered before answers differently.Metadata-protocol: none
Claim: 6691779259 (
domain:server#2, take-over on handoff 6690566522; seat 4's claim 6685332126 released) · Branch:claude/project-thread-s2vcdk· Rulings: option B, sign-in required (6691366983); D1 / D2 / D3 from the seat (D1 below) · Triage: 6685125943 · Contract review: 6691738005 (PASS, 0 must-fix; should-fix 1 and 2 done on this head)维护者速读
read_file读一条记录上的文件(文件字段或附件),返回文本(PDF、Word 在服务端抽取,图片作图片块)、短时下载链接、大小和读取上限;create_upload_link给一条可编辑记录的某个文件字段(或附件列表)发一个 10 分钟内只能用一次、只限本人登录后使用的上传链接,用户在已登录的浏览器里打开选文件即可。get_record同样的 404,下载门会拒的文件答下载门同样的 403。PUT),未登录答 401,不是匿名入口。按你的裁决(方案 B,6691366983),链接只限发链接的本人登录后使用。令牌 256 位随机、库里只存 SHA-256、条件更新保证只用一次、过期 10 分钟;已用/过期/不存在/别人的链接答同一个 404。链接行里记的使用人必须等于该行只读的上传人owner_id,有sys_file编辑权的人改写链接行也拿不到链接。上传以发链接的用户身份重新解析权限后落库,走记录自己的更新门。新依赖unpdf1.9.1 只在@objectstack/mcp、按需加载。回滚即撤 PR。packages/services/service-storage/src/record-file-access.ts(openLink的各项检查,以及consumeUploadLink的顺序:开链、校验、大小门、重解析身份、条件更新花掉链接、落库);②packages/services/service-storage/src/storage-routes.ts的upload-links两条路由;③packages/mcp/src/file-content.ts(PDF/Word 抽取与各上限常量)。pnpm --filter @objectstack/service-storage exec vitest run src/record-file-access.integration.test.ts,预期 22 条全绿:访问跟随记录、大小上限、单次使用(并发两次只成一次)、过期、绑定一条记录一个字段、未登录 401 与他人 404 都不花掉链接、改写过使用人的链接行答 404 且不花掉、超限 413 不花掉链接。Why
#30892: an agent connected over MCP could read a record's fields but not the files on it (a receipt, a signed contract, a photo), and had no way to put a file on a record. The card's ruling is two tools, access following the record, a named size cap, and a short-lived single-use upload link bound to one record and field; direct protocol upload stays out of scope.
What changes
@objectstack/service-storage— the record's file doors, as the caller.record-file-access.ts(new) holds three caller-scoped verbs on thefile-attachmentsservice:listRecordFilesAs,readRecordFileAs,createUploadLinkAs, plus the link door the routes call (openUploadLink,consumeUploadLink).findOneunder the caller's context). Unreadable, missing or refused answers404 RECORD_NOT_FOUND, the sentenceget_recordanswers.sys_attachmentread bound to that record, so an attachment id from another record answers404. A field holding several files asks forfileIdand lists the ids.judgeFileRead, the plugin's, unchanged and bound in; ADR-0104 public-read, attachments parent read, field owner). A refusal answers that door's403 ATTACHMENT_DOWNLOAD_DENIED/FILE_DOWNLOAD_DENIED. A file that is not committed (or deleted and not held) answers404 FILE_NOT_FOUND. No second access judgment.mintDownloadUrlwith the doors' TTL (300 s).createUploadLinkAsasks the record's edit door before minting: attachments useassertMayAttach(the attach door's verdict); a field usesmayEditAttachmentParent(403 PERMISSION_DENIED) and the caller's writable fields (403 FIELD_NOT_ACCESSIBLE). The link is one pendingsys_filerow whosenameholds its state, whosemetadataholds the binding (object, record, field or attachments, user, organization, expiry, secret digest), and whoseowner_id(readonly) is the minting user.401 AUTH_REQUIRED); open (token shape, row, state, digest, expiry, the session's user is the binding's user, and the binding's user is the row'sowner_id); validate the body; the size gate (uploadSizeRefusal,413 PAYLOAD_TOO_LARGE) before anything is spent; re-resolve the minting user (resolveUserAuthzGrantswith the grants cache bypassed, andvetOrganizationClaimon the bound organization; unbacked answers403 PERMISSION_DENIED); the conditional update that spends the link; then the write as that user. A field upload is written throughengine.updateas the user, so the record's write RLS, the field'saccept/maxSizeand the file-reference hooks apply exactly as for any edit; an attachment goes throughattachFileAs. Any refusal after the spend discards the bytes and the row. The link row is deleted after use.metadata-store.ts:updateFileIfUnchanged(store, id, seen, patch)— a conditionalupdateon{ id, status, name }withmulti: true, answering how many rows moved. (The JSONmetadatacannot be the guard: a{…}-shaped string where-value is read as a filter placeholder.)storage-routes.ts:GET /api/v1/storage/upload-links/:token(the page) andPUT …/:token(the upload), mounted throughmountFamilyRoute(the ADR-0069 session gate applies) and ledgeredserver-only(storage-route-ledger.ts). Both read the request's session first and answer401 AUTH_REQUIREDwithout one, failing closed when no session resolver is wired, so neither is an anonymous door (ADR-0138 D2).mintDownloadUrlandDEFAULT_DOWNLOAD_TTLare now exported so the doors and the verbs share them.upload-link-page.tsrenders the page;upload-link-wire.tsholds the one unusable-link status, code and sentence.storage-service-plugin.tsbinds the verbs to the plugin's ownjudgeFileRead, servability check and the kernel's authz resolution (bindRecordFileAccess).@objectstack/mcpread_file(objectName,recordId, andfieldwith optionalfileId, orattachmentId; neither lists the record's files) answers a facts block (id, name, type, size, field or attachment,maxBytes, download link) and then the content: text for text types, PDF and.docx; an MCP image block for an image up to 5 MiB. Over the 10 MiB cap it answers the facts and the link without content.create_upload_link(objectName,recordId, andfieldorattachments: true) answersurl,expiresAt,singleUse: true, the binding, and one line of instructions (open it in a browser where the user is signed in to this deployment).data:read,data:write), refusesys_*objects as the other data tools do, and render refusals with the ADR-0112 envelope.file-content.ts(new): the extractors and the named bounds —READ_FILE_MAX_BYTES10 MiB,READ_FILE_TEXT_MAX_CHARS100 000,READ_FILE_PDF_MAX_PAGES200,READ_FILE_DOCX_XML_MAX_BYTES32 MiB,READ_FILE_IMAGE_MAX_BYTES5 MiB. Every extractor answers a sentence on a bad file (encrypted, damaged, no text layer, not a Word document); a net around them keeps a file from ever failing the tool.skill-md.ts: the servedSKILL.mdteaches each tool only where it is registered (fileToolsoption, scope-gated).@objectstack/runtime—domains/mcp.ts: the bridge's file members read the record throughget_record's door (callData('get')) FIRST, so an unreadable record is that door's refusal and storage is never asked; then call the storage verbs as the request's principal. A local download path and the upload link's path come back absolute on the deployment's public origin (the auth service's MCP resource URL, else the request's host and forwarded proto); an adapter's absolute URL is left as it is. Without the storage service the members are absent and neither tool is listed.Docs —
content/docs/ai/connect-mcp.mdx(two tool rows and a "Files" section),content/docs/api/plugin-endpoints.mdx(the two routes). Two censuses the gates hold to the tree:content/docs/permissions/tenant-audit-census.mdxand its counts ledger (265 engine write call sites on the merged tree:main's 263 plus the link's field write and the conditional spend; regenerated bytenant-audit-census.mjs --write, the page's hand-stated figures moved to match), andcontent/docs/permissions/system-context.mdxrow 47e, which now names the record-file verbs beside the attach verbs (both refuse the system context through arequireUserContext, so the row's key is pinned per file).D1 — the PDF text extractor (seat ruling)
unpdfat exactly1.9.1(pinned, no caret), a dependency of@objectstack/mcponly, loaded by dynamicimport()on the first PDF read. Reasons, as ruled: it is pdf.js packaged for server runtimes with no native dependency (its@napi-rs/canvaspeer is optional and not installed), so the text layer is pdf.js's; 1.9.1 rather than 2.x keeps the engines floor the workspace already has; the dynamic import keeps the cost off every MCP request that never reads a PDF. It runs withisEvalSupported: false(pdf.js compiles no font code witheval),disableFontFace: true,useSystemFonts: false, only on bytes that already passed the 10 MiB cap, and stops at 200 pages or 100 000 characters. A malformed or encrypted PDF answers a sentence, pinned..docxis read withnode:zlib(inflateRawSyncwithmaxOutputLengthat 32 MiB) over a minimal ZIP reader that opens onlyword/document.xml; a decompression bomb answers a sentence, pinned.pnpm-lock.yamlcarries only the threeunpdfhunks.D2 and D3 (seat: as recommended)
sys_filerow plus itsmetadata, so no new object and no spec change.attachFileAs, or the stored file plus the record's own update as that user), so field security, RLS and validation judge it. The alternative, judged at mint and written as the system (the signed-URL precedent), was not taken.The upload-link security surface
UUID.SECRET, where the UUID is the link row's id and the secret is 32 random bytes (randomBytes, 256 bits) in base64url. Shape-checked before any read.metadata; it is compared withtimingSafeEqual. The secret is in no log and no answer after the mint.name: 'upload-link'to'upload-link (used)'whereid,status: 'pending'and the open name still hold, and the spend counts only when exactly one row moved. Two racing uploads: one200, one404(pinned in both suites).pendingand is reaped by the existing pending-upload retention (attachment-lifecycle.ts, 7 days).401 AUTH_REQUIREDon both routes and spends nothing; a session naming another user gets the unusable-link answer below and spends nothing (both pinned).nameandmetadataare notreadonlycolumns, so the link opens only when the binding's user is the row'sowner_id, the readonly uploader column written at mint. A row whose binding was rewritten to name another user answers the unusable-link404to that user and to the minting user alike, and is not spent (pinned, ablated). The binding's other columns (record, field, organization) rest onsys_fileedit being an administrative grant, as the module doc states.404with one page; upload404 RESOURCE_NOT_FOUND, "This upload link cannot be used: it was used already, it has expired, it was made for someone else, or it does not exist. Ask for a new link."Content-Security-Policywith a per-response nonce (default-src 'none'; script-src 'nonce-…'; style-src 'nonce-…'; connect-src 'self'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'),Cache-Control: no-store,Referrer-Policy: no-referrer,X-Robots-Tag: noindex,X-Content-Type-Options: nosniff,X-Frame-Options: DENY. It names no record and no user. Its scriptPUTs to its own address withcredentials: 'same-origin', carrying the session cookie the route asks for.403, nothing lands; pinned). The binding comes from the row only; headers and query strings naming another record or field are ignored (pinned).Tests
packages/services/service-storage/src/record-file-access.integration.test.ts(new, 22): a real ObjectQL on SQLite with the plugin's own wiring and a write-side RLS fixture — read and its cap, access on read (unreadable record, download-door refusal), multi-file fields, a non-file field, attachments and the listing; the mint (token shape, TTL, secret not stored), access on mint (404,403 PERMISSION_DENIED, attachments parent), the page's headers, the binding (headers ignored), the attachments link, no session (401, not spent), another signed-in user (404, not spent), a row whose binding was rewritten to another user (404for both users, not spent), single use (forged, unknown, malformed: one404), the race, expiry, the413that does not spend the link, revoked access (403, nothing lands), and a field'smaxSizerefused with the bytes removed.packages/mcp/src/mcp-file-tools.test.ts(new, 21): both tools over the HTTP transport, argument refusals, the listing, a real PDF's text throughunpdf, a damaged PDF, a.docx's text, a.docxbomb, an image block, the over-cap answer, and the skill lines per scope.packages/runtime/src/mcp-record-file-bridge.test.ts(new, 5): the record is read throughget_record's door first, the principal, absolute links, no members without the seam.packages/qa/dogfood/test/showcase-mcp-record-files.dogfood.test.ts(new): the booted showcase with security, storage and MCP, a member and the admin over/api/v1/mcp—showcase_invoice_line.receipt(a file field undercontrolled_by_parent) andshowcase_invoiceattachments (owner RLS): mint, page, upload with a forged target, anonymous401and another signed-in user's404without spending the link, the committedsys_filerow of its byte size, second use, unknown token, expiry,read_fileof the PDF's text and its download bytes, the admin's records refused withget_record's code on both tools, an attachment of another record unreadable through the member's own, the race, and the413before the spend.Acceptance notes
401. The maintainer ruled B: the link works only for the user it was minted for, signed in, built in04ff76bc. Both routes answer401 AUTH_REQUIREDwithout a session, and the sweep holds unchanged with no ADR change. The cost, accepted with the ruling: a person opening the link on a phone that is not signed in signs in there first, a forwarded link works for nobody else, and an agent uploading by itself needs a deployment credential. Option A (a bearer link plus an ADR-0138 amendment) is not taken.openLink) is in8d23d823; should-fix 2 is this body. Should-fix 3 (a signed-out browser opening the link gets a JSON401, not a sign-in page) is a follow-up card for the storage lane; the401rule is unchanged here.main(with feat(mcp)!: delete_record refuses a call without confirm: true #30861, which also touchesmcp-http-tools.ts, the unknown-argument sweep andconnect-mcp.mdx) is merged in atc5fe7df9; that merge was clean.mainwas merged again at57724d86, where the only conflict was the tenant-audit census: both sides had moved its figures, so the artefacts were taken frommain, regenerated over the merged tree, and the page's hand-stated figures moved to match (check-tenant-audit-censusgreen). The branch was already pushed, so it was not rebased.service-storage). An upload through a link meets the samestorage.max_upload_mbgate before the link is spent, writes the same committedsys_filerow with its size, and the samestorage.upload, so whatever a host counts toward quota counts it. A byte quota is cloud's half; nothing here needs it.acceptormaxSize, revoked access) spends the link; the agent asks for a new one. The size gate runs before the spend so the common refusal does not.basePath. A host that mounts the storage family under another base would need the link built from that base.getWritableFieldsis advisory at mint; the write itself goes through the record's update door, which is the judgment that holds.packages/mcp/package.jsonandpnpm-lock.yaml(the D1 dependency),content/docs/api/plugin-endpoints.mdx(the two routes), the two census pages and the counts ledger (gate-forced, above), and the route-ledger parity dogfood's comment. Insideservice-storagebeyond calling existing doors: the link routes, the page, and the conditional update inmetadata-store.ts, all of them the one single-use upload-link record the claim allows.UploadLinkDoor) and the bound-target types are where an MCP-side upload would attach later.Verification
Head 57724d8: the owner check (contract review should-fix 1, 8d23d82) and the second main merge
record-file-access.integration.test.ts: 22 of 22.@objectstack/service-storagewhole suite: 74 files, 1389 of 1389 (one more than67847d9c's 1388: the new pin).scripts/ablation-replace.mjs --deleteofif (row.owner_id !== state.user_id) refuseUnusableLink();(anchor 1 → 0, blob e63efe2f → 7625cc64): "a row whose binding names another user than its stamped uploader is no link" turned red,AssertionError: expected 200 to be 404(the rebound row opened for the other user), Tests 1 failed, 21 passed of 22. Restored: blob equals HEAD (e63efe2f),git diff HEADempty.57724d86(install, full workspace build with declarations, 119 of 119 tasks):@objectstack/service-storage74 files, 1389 of 1389;@objectstack/mcp58 files, 714 of 714;@objectstack/runtimemcp-record-file-bridge.test.ts5 of 5;typecheckon the three packages andtsc --noEmiton the dogfood exit 0; dogfoodshowcase-mcp-record-files,anonymous-door-sweep,authz-conformance,route-ledger-live-mount-parityandmcp-system-object-action-exposure: 5 files, 114 of 114.57724d86for this round's delta (the tworecord-file-accessfiles and the two census artefacts): 84 derived, 84 run, every one exit 0;dispatch-gates --ranreconciles 84 / 84 / 0 not measured. (On8d23d823, before the merge, the 53 derived for the two code files also passed 53 of 53.) Two of them needed outputs first and are counted after:check:dual-build-cjs-loadsandcheck:i18nexit 3 until a workspace build, andcheck-plugin-teardown-shape --self-testuntil its control commit is fetched into the shallow clone.Earlier heads
04ff76bc: the derived gates passed 111 of 111.dispatch-gates --ranreconciled them as 111 derived, 111 run, 0 not measured. One gate,check-comment-mask-corpus, failed with ENOENT the first time because it ran alongside the dogfood and read a temporaryobjectstack.config.bundled_*.mjs; rerun after the dogfood finished, it passed (21155 files, 0 disagree). The full dogfood passed:os-verify-lock: VERDICT command-exit 0, Test Files 426 passed and 3 skipped of 429, Tests 3556 passed and 41 skipped of 3597, 0 failed. That includes the anonymous-door sweep and the authz-conformance pin.c5fe7df9(after the merge) and67847d9c:@objectstack/service-storage74 files and 1388 of 1388 tests;@objectstack/mcp58 files and 711 of 711 tests (with feat(mcp)!: delete_record refuses a call without confirm: true #30861's);@objectstack/runtimemcp-record-file-bridge.test.ts5 of 5;tsc --noEmiton all three packages and the dogfood exits 0; 111 of 111 derived gates; the full dogfood on the merge tree: Test Files 429 passed and 3 skipped of 432, Tests 3573 passed and 41 skipped of 3614, 0 failed. CI on67847d9c: 59 success, 7 skipped, 0 failed.if (!spent)toif (spent < 0)turns "of two concurrent uses exactly one lands" red (1 failed of 19 at the time); removingdeadline <= this.now()turns the expiry test red. Both restored, blobs equal to HEAD.ebd0dbc4ande3f0aa4ealso passed 111 of 111 gates. Onbc5ad15b(before B), the full dogfood had 2 failures: the authz pin (fixed inbc5ad15b) and the anonymous-door sweep, which led to the sign-in choice the maintainer ruled above.Human Minimum
packages/specholds no runtime logic. (packages/specis not touched.)pnpm lintis CI's run.main, one pull request.packages/spectouched: not applicable.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.